跳到主要内容

Unity 编写一个简易的 UI 框架

什么是 UI 框架

UI 框架用于管理场景中的所有面板(就是以一个面板为单位进行控制),控制面板之间的切换,可以加快开发进度、提高代码质量。

根据用户界面调用情况,分析有如下四种状态:

  1. 进入状态:界面第一次被动态加载使用的时候
  2. 暂停状态:切换到其他界面的时候
  3. 继续状态:重新回到界面的时候
  4. 退出状态:界面不显示的时候

实现步骤:

1、使用 JSON 保存面板路径,枚举保存面板类型

2、根据界面共有的四种状态,创建 UI 基类 BasePanel,场景中的界面继承该基类,并将四种状态写成虚方法,依次分别为 OnEnter(),OnPause(),OnResume(),OnExit(),提供给子类重写。

3、通过管理类 UIManager,解析 JSON,管理 UI 界面的加载和切换。为了方便调用,做成单例模式。分别用两个字典保存从 JSON 读取的面板信息和已动态加载实例化的面板。通过栈来管理场景中所有面板之间的切换。

注意:因为用栈来存储场景中依次打开的界面,也只能依次从栈顶界面开始关闭。

页面状态流程图如下所示:

UI 框架类图

UI 类型枚举

将状态面板类型记录在枚举中

namespace UIFrame
{
public enum UIPanelType
{
/// <summary>
/// 主界面的登陆窗口
/// </summary>
LoginPanel,
/// <summary>
/// 选择模式的窗口
/// </summary>
SelectModePanel,
/// <summary>
/// 设置窗口
/// </summary>
SettingPanel,
/// <summary>
/// 开始界面创建
/// </summary>
StartPanel,
/// <summary>
/// 故事模式窗口
/// </summary>
StoryModePanel
}
}

它对应预制件的名字和位置(要放在 Resources 文件夹里面):

JSON 数据

注意,这个 panelType 要对应上面的枚举里的名称

[
{
"panelType": "LoginPanel",
"path": "Prefabs/UI/Panel/LoginPanel"
},
{
"panelType": "SelectModePanel",
"path": "Prefabs/UI/Panel/SelectModePanel"
},
{
"panelType": "SettingPanel",
"path": "Prefabs/UI/Panel/SettingPanel"
},
{
"panelType": "StartPanel",
"path": "Prefabs/UI/Panel/StartPanel"
},
{
"panelType": "StoryModePanel",
"path": "Prefabs/UI/Panel/StoryModePanel"
}
]

UI 界面基类

注意这里的 CanvasGroup 组件可以用来控制一组 UI 元素的某些方面(如同它名字一样,用于管理一组 UI),CanvasGroup 的属性会影响他所有 children 的 GameObject

创建一个基础 UI 抽象类,所有的 UI类都继承这个类就可以了。

namespace UIFrame
{
/// <summary>
/// 所有UI的父类
/// 用来控制UI的状态
/// </summary>
public abstract class BasePanel : MonoBehaviour
{
// UI 是否被初始化
private bool isInit = false;

public abstract UIPanelType uiType { get; }

private bool isPause = false;

// UI 是否被冻结(暂停)
internal bool IsPause
{
get => this.isPause;
set
{
this.isPause = value;
if (value)
{
OnPause();
}
else
{
OnResume();
}
}
}

private CanvasGroup mCanvasGroup;

private void Start()
{
InitSuper();
}

/// <summary>
/// 启动时初始化,它会自动被调用,UI Manager 无需关注这块内容
/// </summary>
public void InitSuper()
{
if (isInit)
return;

isInit = true;

// CanvasGroup 组件可以用来控制一组 UI 元素的某些方面(如同它名字一样,用于管理一组 UI),
// CanvasGroup 的属性会影响他所有 children 的 GameObject

mCanvasGroup = GetComponent<CanvasGroup>();

// 注意,因为这里是基类,所以无法通过 [RequireComponent(typeof(CanvasGroup))] 影响子类,
// 所以需要这里手动添加
if(mCanvasGroup == null){
gameObject.AddComponent<CanvasGroup> ();
mCanvasGroup = GetComponent<CanvasGroup> ();
}

gameObject.SetActive(true);

// 这里会自动把当前面板下的所有 Button 注册进这个委托里面
var buttons = GetComponentsInChildren<Button>();
foreach (var item in buttons)
{
var btn = (Button) item;
btn.onClick.AddListener(delegate { this.DidOnClick(btn.gameObject); });
}

OnInitUI();
}

/// <summary>
/// 打开时的动画
/// </summary>
public void OpenActivity()
{
// 可以通过这个 mCanvasGroup 控制全部子对象
// mCanvasGroup.alpha = 1;

gameObject.SetActive(true);
// 从左向右的滑动动画
var temp = transform.localPosition;
temp.x = -800;
transform.localPosition = temp;
transform.DOLocalMoveX(0, 0.5f);
}

/// <summary>
/// 关闭时的动画
/// </summary>
public void CloseActivity()
{
transform.DOLocalMoveX(-800, .5f).OnComplete(() => gameObject.SetActive(false));

/*var outPos = mTransform.position.x - Screen.width;
mTransform.DOMoveX(outPos, 0.2f).OnComplete(delegate { });*/
}

/// <summary>
/// 在Start中初始化UI的操作
/// </summary>
public virtual void OnInitUI()
{
}

/// <summary>
/// 界面显示出来
/// </summary>
public virtual void OnEnter()
{
OpenActivity();
}

/// <summary>
/// 界面暂停(弹出了其他界面)
/// </summary>
public virtual void OnPause()
{
}

/// <summary>
/// 界面继续(其他界面移除,回复本来的界面交互)
/// </summary>
public virtual void OnResume()
{
}

/// <summary>
/// 界面不显示,退出这个界面,界面被关闭
/// </summary>
public virtual void OnExit()
{
CloseActivity();
}

/// <summary>
/// 注册按钮点击事件
/// </summary>
/// <param name="sender">发送事件的按钮</param>
public abstract void DidOnClick(GameObject sender);
}
}

可以注意到这里通过把 DidOnClick 方法委托出去,它可以接受到这个面板下面的所有按钮点击事件

创建测试 Menu 类

此时已经可以直接使用这个面板了

public class TestMenuUI : BasePanel
{

public override UIPanelType uiType => UIPanelType.StartPanel;

/// <summary>
/// 这里能收到这个面板下的所有 Button 的消息
/// 所以根据名字来判断它的职责
/// </summary>
/// <param name="sender"></param>
public override void DidOnClick(GameObject sender)
{
switch (sender.name) {
case "Start Game Button":
Debug.Log("游戏开始了~");
break;
default:
break;
}
}
}

将其直接挂载在这个 Start Panel 根对象上

启动后点击按钮,就可以发现打印了这个 "游戏开始了~"

下面开始编写 UI Manager

编写 UI Manager

注意,要让所有的面板都编写一个脚本继承自上面的基类,然后将其挂载上去

这里使用的 JSON 框架是 Newtonsoft.Json

namespace UIFrame
{
/// <summary>
/// 面板管理器
/// </summary>
public class UIManager : Singleton<UIManager>
{
//字典存储所有面板的 Prefabs 路径
private Dictionary<UIPanelType, string> panelPathDict;

//保存所有已实例化面板的游戏物体身上的BasePanel组件
private readonly Dictionary<UIPanelType, BasePanel> panelDict;

//存储当前场景中的界面
private readonly Stack<BasePanel> panelStack;

// 页面的画布
private Transform canvasTransform;

private Transform CanvasTransform
{
// 因为场景里的画布可能会随着场景销毁而销毁,但是 UIManager 并不会销毁,
// 所以需要通过这个机制保证每次都能取得场景的画布
get
{
if (canvasTransform == null)
{
canvasTransform = GameObject.Find("Canvas").transform;
}

return canvasTransform;
}
}

public UIManager()
{
panelPathDict = new Dictionary<UIPanelType, string>();
panelDict = new Dictionary<UIPanelType, BasePanel>();
panelStack = new Stack<BasePanel>();
}


private void Start()
{
// 解析JSON,获取所有面板的路径信息
LoadJsonTool.ParseUIPanelTypeJsonData(ref panelPathDict);
// 把场景里面的已经存在的 UI 实例塞进字典
var panels = FindObjectsOfType<BasePanel>().ToList();
panels.Sort((x, y) => x.transform.GetSiblingIndex() - y.transform.GetSiblingIndex()); // 升序
panels.ForEach(x =>
{
panelDict.Add(x.uiType, x);
// 入栈
panelStack.Push(x);
Debug.Log($"当前入栈的是:{x.uiType} 它的索引为:{x.transform.GetSiblingIndex()}");
});

}

/// <summary>
/// 根据面板类型,返回对应的BasePanel组件
/// </summary>
/// <param name="panelType">需要返回的面板类型</param>
/// <returns>返回该面板组件</returns>
private BasePanel GetPanel(UIPanelType panelType)
{
panelDict.TryGetValue(panelType, out var basePanel);
//如果panel为空,根据该面板 prefab 的路径,实例化该面板
if (basePanel == null)
{
var path = panelPathDict[panelType];
var newPanel = GameObject.Instantiate(Resources.Load<GameObject>(path)) as GameObject;
newPanel.transform.SetParent(CanvasTransform, false);

//第一次实例化的面板需要保存在字典中
panelDict.Add(panelType, newPanel.GetComponent<BasePanel>());
return newPanel.GetComponent<BasePanel>();
}
else
{
return basePanel;
}
}

/// <summary>
/// 设置默认的栈顶元素
/// </summary>
/// <param name="panelType">界面类型</param>
/// <param name="basePanel">组件</param>
public void SetDefaultPopPanel(UIPanelType panelType, BasePanel basePanel)
{
panelDict.Add(panelType, basePanel);
panelStack.Push(basePanel);
}

/// <summary>
/// 把该页面显示在场景中
/// </summary>
/// <param name="panelType">需要显示界面的类型</param>
public void PushPanel(UIPanelType panelType)
{
//判断一下栈里面是否有页面
if (panelStack.Count > 0)
{
panelStack.Peek().IsPause = true; //原栈顶界面暂停
}

var panel = GetPanel(panelType);
panel.OnEnter(); //调用进入动作
panelStack.Push(panel); //页面入栈
}

/// <summary>
/// 关闭栈顶界面显示
/// </summary>
public void PopPanel()
{
//当前栈内为空,则直接返回
if (panelStack.Count <= 0) return;
panelStack.Pop().OnExit(); //Pop删除栈顶元素,并关闭栈顶界面的显示,
if (panelStack.Count <= 0) return;
panelStack.Peek().IsPause = false; //获取现在栈顶界面,并调用界面恢复动作
}
}
}

因为界面本身可能就已经存在某些打开的面板了,所有需要把这些已经存在的面板加载进来,但是这里需要判断谁先入栈,所以需要根据已存在的 UI 面板的索引位置来判断谁先谁后(在下面的 UI 会显示在最上面)

var panels = FindObjectsOfType<BasePanel>();
foreach (var basePanel in panels)
{
// 取得索引
Debug.Log($"{basePanel.uiType} 的索引为:{basePanel.transform.GetSiblingIndex()}");
}

测试它们的上下顺序是否与这个索引有关

可以发现在下面的索引值比较大

补充一下这个获取索引相关的方法:

属性

  • Transform.parent:设置和获取父级对象。
  • Transform.root:获取层次最高的对象。
  • Transform.childCount:获取子级对象的数量。

方法

  • Transform.Find:根据名字寻找子项。
  • Transform.IsChildOf:判断是否为指定Transform对象的子项。
  • Transform.DetachChildren:解除所有子项。
  • Transform.GetChild:根据索引获取子项。
  • Transform.GetSiblingIndex:获取同一级别的物体的索引。
  • Transform.SetAsFirstSibling:设置为同一级别的物体为第一个索引。
  • Transform.SetAsLastSibling:设置为同一级别的物体为最后一个索引。
  • Transform.SetSiblingIndex:设置同一级别的物体的索引。

编写测试类

使用 IMGUI 编写一个测试方法

public class TestControllerGUI : MonoBehaviour
{
private void OnGUI()
{
if (GUILayout.Button("打开开始界面"))
{
UIManager.instance.PushPanel(UIPanelType.StartPanel);
}
}
}

并在这个开始界面里设置一个关闭窗口的按钮事件(这里点击 Play 关闭窗口)

public class StartPanelController : BasePanel
{
public override void DidOnClick(GameObject sender)
{
// 加上这个,可以让被锁住的页面的按钮事件无效
if (IsPause) return;

switch (sender.name) {
case "Start Game Button":
// Debug.Log("游戏开始了~");
UIManager.instance.PopPanel();
break;
default:
break;
}
}
}

如下效果所示:

如果对这个特效不满意,可以自己重写

Reference

参考资料 Unity3D -- 简单的UI管理结构 参考资料 Unity3D之搭建简易有效的UI框架